<!DOCTYPE html>
<html lang="en-US">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>实现一个大文件上传和断点续传 | 惊蛰的博客</title>
    <meta name="generator" content="VuePress 1.8.2">
    <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.11.2/css/all.css">
    <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.11.2/css/v4-shims.css">
    <script src="https://use.fontawesome.com/releases/v5.11.2/js/all.js"></script>
    <script src="https://use.fontawesome.com/releases/v5.11.2/js/v4-shims.js"></script>
    <!--    #代码高亮引入必要库-->
    <link href="https://cdn.bootcss.com/highlight.js/8.0/styles/monokai_sublime.min.css" rel="stylesheet">
    <script src="https://cdn.bootcss.com/highlight.js/8.0/highlight.min.js"></script>
    <!--    动画库-->
    <link rel="stylesheet" href="https://www.kettle.net.cn/animate/animate.min.css">
    <!--    评论-->
    <script src="//unpkg.com/valine/dist/Valine.min.js"></script>
    <!--    aplayer-->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/aplayer@1.10.0/dist/APlayer.min.css">
    <script src="https://cdn.jsdelivr.net/npm/aplayer@1.10.0/dist/APlayer.min.js"></script>
    <!-- require MetingJS -->
    <script src="https://cdn.jsdelivr.net/npm/meting@2/dist/Meting.min.js"></script>
    <!--    jq-->
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
    <!--    backstretch 轮播图-->
    <script type="text/javascript" src="//cdn.bootcss.com/jquery-backstretch/2.0.4/jquery.backstretch.min.js"></script>
    <!-- 引用 bbtalk -->
    <script src="https://cdn.jsdelivr.net/npm/bbtalk@0.1.5/dist/bbtalk.min.js"></script>
    
    <meta name="description" content="惊蛰的博客">
    
    <link rel="preload" href="/assets/css/0.styles.c076dd71.css" as="style"><link rel="preload" href="/assets/js/app.8e64649f.js" as="script"><link rel="preload" href="/assets/js/17.9b1a1e45.js" as="script"><link rel="preload" href="/assets/js/4.84c4889c.js" as="script"><link rel="preload" href="/assets/js/32.b87582bb.js" as="script"><link rel="prefetch" href="/assets/js/1.19f63e1b.js"><link rel="prefetch" href="/assets/js/10.76457569.js"><link rel="prefetch" href="/assets/js/11.2fabe50b.js"><link rel="prefetch" href="/assets/js/12.29f3cbb3.js"><link rel="prefetch" href="/assets/js/13.5bbd6907.js"><link rel="prefetch" href="/assets/js/14.97c38b2b.js"><link rel="prefetch" href="/assets/js/15.a6ea704f.js"><link rel="prefetch" href="/assets/js/16.bf8c8fc8.js"><link rel="prefetch" href="/assets/js/18.bcc6785d.js"><link rel="prefetch" href="/assets/js/19.af8c6c71.js"><link rel="prefetch" href="/assets/js/2.63f6fb06.js"><link rel="prefetch" href="/assets/js/20.04b27695.js"><link rel="prefetch" href="/assets/js/21.0ecdd73f.js"><link rel="prefetch" href="/assets/js/22.5a2b263f.js"><link rel="prefetch" href="/assets/js/23.a9412068.js"><link rel="prefetch" href="/assets/js/24.aedb6bde.js"><link rel="prefetch" href="/assets/js/25.dfc2456b.js"><link rel="prefetch" href="/assets/js/26.e8e572f6.js"><link rel="prefetch" href="/assets/js/27.0fd0718f.js"><link rel="prefetch" href="/assets/js/28.60ae6b13.js"><link rel="prefetch" href="/assets/js/29.701ebcc0.js"><link rel="prefetch" href="/assets/js/3.315403c1.js"><link rel="prefetch" href="/assets/js/30.9b75bf1e.js"><link rel="prefetch" href="/assets/js/31.67f8b095.js"><link rel="prefetch" href="/assets/js/33.7c0ab263.js"><link rel="prefetch" href="/assets/js/34.d4c2e482.js"><link rel="prefetch" href="/assets/js/35.88a27070.js"><link rel="prefetch" href="/assets/js/36.ccd7bbb9.js"><link rel="prefetch" href="/assets/js/37.a4ec9e64.js"><link rel="prefetch" href="/assets/js/38.888b51fa.js"><link rel="prefetch" href="/assets/js/39.822d40c5.js"><link rel="prefetch" href="/assets/js/40.76c692f0.js"><link rel="prefetch" href="/assets/js/5.94499d86.js"><link rel="prefetch" href="/assets/js/6.5934ba2a.js"><link rel="prefetch" href="/assets/js/8.b3a395d6.js"><link rel="prefetch" href="/assets/js/9.81a3105a.js">
    <link rel="stylesheet" href="/assets/css/0.styles.c076dd71.css">
</head>
<body>
<div id="app" data-server-rendered="true"><div><div data-v-43936cb4><div class="container" data-v-43936cb4 data-v-43936cb4><div class="ordinary" data-v-43936cb4><div data-v-43936cb4><a target="_self" href="/" class="title flat-box waves-effect waves-block author" data-v-43936cb4>
                        jingzhe
                    </a></div> <div class="menu navigation" data-v-43936cb4><ul class="nav-list-h m-pc" data-v-43936cb4><li data-v-43936cb4><a href="/" id="home" class="menuitem flat-box faa-parent animated-hover waves-effect waves-block" data-v-43936cb4><i class="fa fa-home fa-fw" data-v-43936cb4></i>博客
                            </a></li> <li data-v-43936cb4><a href="/archives/" id="archives" class="menuitem flat-box faa-parent animated-hover waves-effect waves-block" data-v-43936cb4><i class="fa fa-archive fa-fw" data-v-43936cb4></i>归档
                            </a></li> <li data-v-43936cb4><a href="/friends/" id="friends" class="menuitem flat-box faa-parent animated-hover waves-effect waves-block" data-v-43936cb4><i class="fa fa-link fa-fw" data-v-43936cb4></i>友链
                            </a></li> <li data-v-43936cb4><a href="/aboutPage/" id="about" class="menuitem flat-box faa-parent animated-hover waves-effect waves-block" data-v-43936cb4><i class="fa fa-info-circle fa-fw" data-v-43936cb4></i>关于
                            </a></li> <li data-v-43936cb4><a id="other" class="menuitem flat-box faa-parent animated-hover waves-effect waves-block" data-v-43936cb4><i class="fa fa-info-circle fa-fw" data-v-43936cb4></i>其他
                            </a> <ul class="list-v" data-v-43936cb4><li data-v-43936cb4><a class="menuitem flat-box header toggle-mode-btn cutover" data-v-43936cb4><i class="fas fa-moon fa-fw" data-v-43936cb4></i> 暗黑模式
                                    </a></li> <li data-v-43936cb4><hr data-v-43936cb4></li> <li data-v-43936cb4><a href="/bb/" class="menuitem flat-box faa-parent animated-hover" data-v-43936cb4>
                                        哔哔哔
                                    </a></li> <li data-v-43936cb4><a target="_blank" href="https://blog.jingzhe.xyz" class="menuitem flat-box faa-parent animated-hover" data-v-43936cb4>
                                        旧版博客
                                    </a></li> <hr data-v-43936cb4> <li data-v-43936cb4><a href="/categories/" class="menuitem flat-box faa-parent animated-hover" data-v-43936cb4><i class="fa fa-folder-open fa-fw" data-v-43936cb4></i>
                                        分类
                                    </a></li> <li data-v-43936cb4><a href="/tags/" id="v2getting-started" class="menuitem flat-box faa-parent animated-hover" data-v-43936cb4><i class="fa fa-tags fa-fw" data-v-43936cb4></i> 标签
                                    </a></li></ul></li></ul></div> <div class="m_search" data-v-43936cb4><form name="searchform" class="form u-search-form" data-v-43936cb4><i class="icon fa fa-search fa-fw" data-v-43936cb4></i> <input type="text" placeholder="Search..." class="input u-search-input" data-v-43936cb4></form></div></div> <div class="paper" data-v-43936cb4></div></div></div> <div id="archives-pjax" class="paper" data-v-bb8ac3a0><div class="homeCover" data-v-bb8ac3a0></div> <div class="row" data-v-bb8ac3a0><div class="col-span-24" data-v-bb8ac3a0><div id="anchorPoint" data-v-bb8ac3a0></div></div> <div id="main-body-pjax" class="main-body" data-v-bb8ac3a0><div class="paper-page" data-v-bb8ac3a0><div show="true" class="homepage" data-v-9fc62fe0 data-v-bb8ac3a0><div class="post-list" data-v-9fc62fe0 data-v-9fc62fe0><div class="post-wrapper" data-v-9fc62fe0><div class="headimg-div" data-v-9fc62fe0><!----></div> <div class="meta" data-v-9fc62fe0><h2 data-v-9fc62fe0>实现一个大文件上传和断点续传</h2> <div data-v-9fc62fe0><div class="author" data-v-9fc62fe0><a href="https://blog.jingzhe.xyz" rel="nofollow" class="author-div" data-v-9fc62fe0><img src="https://blog.jingzhe.xyz/jingzhe/jpg/1593256062295.jpeg" data-v-9fc62fe0> <a data-v-9fc62fe0>Jingzhe</a></a></div> <div class="time" data-v-9fc62fe0><div data-v-9fc62fe0><i aria-hidden="true" class="fa fa-calendar-alt fa-fw" data-v-9fc62fe0></i> <span data-v-9fc62fe0>2020-07-31T20:42:22.000Z</span></div></div> <div class="guest" data-v-9fc62fe0><i class="fa fa-comment-dots fa-fw" data-v-9fc62fe0></i> <span data-v-9fc62fe0>9</span></div></div></div> <div class="article" data-v-9fc62fe0><div class="leancloud_visitors" data-v-9fc62fe0><div class="content__default" data-v-9fc62fe0><h4 id="本文将从零搭建前端和服务端-实现一个大文件上传和断点续传的-demo"><a href="#本文将从零搭建前端和服务端-实现一个大文件上传和断点续传的-demo" class="header-anchor">#</a> 本文将从零搭建前端和服务端，实现一个大文件上传和断点续传的 demo</h4> <h2 id="前言"><a href="#前言" class="header-anchor">#</a> 前言</h2> <p>本文将从零搭建前端和服务端，实现一个大文件上传和断点续传的 demo</p> <p>前端：vue element-ui</p> <p>服务端：nodejs</p> <p>文章有误解的地方，欢迎指出，将在第一时间改正，有更好的实现方式希望留下你的评论</p> <p>大文件上传</p> <h2 id="整体思路"><a href="#整体思路" class="header-anchor">#</a> 整体思路</h2> <h3 id="前端"><a href="#前端" class="header-anchor">#</a> 前端</h3> <p>前端大文件上传网上的大部分文章已经给出了解决方案，核心是利用 Blob.prototype.slice 方法，和数组的 slice 方法相似，调用的 slice 方法可以返回原文件的某个切片</p> <p>这样我们就可以根据预先设置好的切片最大数量将文件切分为一个个切片，然后借助 http 的可并发性，同时上传多个切片，这样从原本传一个大文件，变成了同时传多个小的文件切片，可以大大减少上传时间</p> <p>另外由于是并发，传输到服务端的顺序可能会发生变化，所以我们还需要给每个切片记录顺序</p> <h3 id="服务端"><a href="#服务端" class="header-anchor">#</a> 服务端</h3> <p>服务端需要负责接受这些切片，并在接收到所有切片后合并切片</p> <p>这里又引伸出两个问题</p> <h4 id="何时合并切片-即切片什么时候传输完成-如何合并切片"><a href="#何时合并切片-即切片什么时候传输完成-如何合并切片" class="header-anchor">#</a> 何时合并切片，即切片什么时候传输完成？如何合并切片？</h4> <p>第一个问题需要前端进行配合，前端在每个切片中都携带切片最大数量的信息，当服务端接受到这个数量的切片时自动合并，也可以额外发一个请求主动通知服务端进行切片的合并</p> <p>第二个问题，具体如何合并切片呢？这里可以使用 nodejs 的 api fs.appendFileSync，它可以同步地将数据追加到指定文件，也就是说，当服务端接受到所有切片后，先创建一个最终的文件，然后将所有切片逐步合并到这个文件中</p> <p>talk is cheap,show me the code，接着我们用代码实现上面的思路</p> <h3 id="前端部分"><a href="#前端部分" class="header-anchor">#</a> 前端部分</h3> <p>前端使用 Vue 作为开发框架，对界面没有太大要求，原生也可以，考虑到美观使用 element-ui 作为 UI 框架</p> <h4 id="上传控件"><a href="#上传控件" class="header-anchor">#</a> 上传控件</h4> <p>首先创建选择文件的控件，监听 change 事件以及上传按钮</p> <div class="language- extra-class"><pre class="language-text"><code>&lt;template&gt;
   &lt;div&gt;
    &lt;input type=&quot;file&quot; @change=&quot;handleFileChange&quot; /&gt;
    &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
export default {
  data: () =&gt; ({
    container: {
      file: null
    }
  }),
  methods: {
    async handleFileChange(e) {
      const [file] = e.target.files;
      if (!file) return;
      Object.assign(this.$data, this.$options.data());
      this.container.file = file;
    },
    async handleUpload() {}
  }
};
&lt;/script&gt;\
</code></pre></div><h4 id="请求逻辑"><a href="#请求逻辑" class="header-anchor">#</a> 请求逻辑</h4> <p>考虑到通用性，这里没有用第三方的请求库，而是用原生 XMLHttpRequest 做一层简单的封装来发请求</p> <div class="language- extra-class"><pre class="language-text"><code>request({
      url,
      method = &quot;post&quot;,
      data,
      headers = {},
      requestList
    }) {
      return new Promise(resolve =&gt; {
        const xhr = new XMLHttpRequest();
        xhr.open(method, url);
        Object.keys(headers).forEach(key =&gt;
          xhr.setRequestHeader(key, headers[key])
        );
        xhr.send(data);
        xhr.onload = e =&gt; {
          resolve({
            data: e.target.response
          });
        };
      });
    }
</code></pre></div><h4 id="上传切片"><a href="#上传切片" class="header-anchor">#</a> 上传切片</h4> <p>接着实现比较重要的上传功能，上传需要做两件事</p> <h4 id="对文件进行切片"><a href="#对文件进行切片" class="header-anchor">#</a> 对文件进行切片</h4> <p>将切片传输给服务端</p> <div class="language-&lt;template&gt; extra-class"><pre class="language-text"><code>  &lt;div&gt;
    &lt;input type=&quot;file&quot; @change=&quot;handleFileChange&quot; /&gt;
    &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
const LENGTH = 10; // 切片数量

export default {
  data: () =&gt; ({
    container: {
      file: null,
    data: []
    }
  }),
  methods: {
    request() {},
    async handleFileChange() {},
   // 生成文件切片
   createFileChunk(file, length = LENGTH) {
     const fileChunkList = [];
     const chunkSize = Math.ceil(file.size / length);
     let cur = 0;
     while (cur &lt; file.size) {
       fileChunkList.push({ file: file.slice(cur, cur chunkSize) });
       cur += chunkSize;
     }
     return fileChunkList;
   },
  // 上传切片
   async uploadChunks() {
     const requestList = this.data
       .map(({ chunk }) =&gt; {
         const formData = new FormData();
         formData.append(&quot;chunk&quot;, chunk);
+					 formData.append(&quot;hash&quot;, hash);
         formData.append(&quot;filename&quot;, this.container.file.name);
         return { formData };
       })
       .map(async ({ formData }) =&gt;
         this.request({
           url: &quot;http://localhost:3000&quot;,
           data: formData
         })
       );
     await Promise.all(requestList); // 并发切片
   },
   async handleUpload() {
     if (!this.container.file) return;
     const fileChunkList = this.createFileChunk(this.container.file);
     this.data = fileChunkList.map(({ file }，index) =&gt; ({
       chunk: file,
       hash: this.container.file.name &quot;-&quot; index // 文件名 数组下标
     }));
     await this.uploadChunks();
   }
  }
};
&lt;/script&gt;
</code></pre></div><p>当点击上传按钮时，调用 createFileChunk 将文件切片，切片数量通过一个常量 Length 控制，这里设置为 10，即将文件分成 10 个切片上传</p> <p>createFileChunk 内使用 while 循环和 slice 方法将切片放入 fileChunkList 数组中返回</p> <p>在生成文件切片时，需要给每个切片一个标识作为 hash，这里暂时使用文件名 下标，这样后端可以知道当前切片是第几个切片，由于之后的合并切片</p> <p>随后调用 uploadChunks 上传所有的文件切片，将文件切片，切片 hash，以及文件名放入 FormData 中，再调用上一步的 request 函数返回一个 proimise，最后调用 Promise.all 并发上传所有的切片</p> <h4 id="发送合并请求"><a href="#发送合并请求" class="header-anchor">#</a> 发送合并请求</h4> <p>这里使用整体思路中提到的第二种合并切片的方式，即前端主动通知服务端进行合并，所以前端还需要额外发请求，服务端接受到这个请求时主动合并切片</p> <div class="language-&lt;template&gt; extra-class"><pre class="language-text"><code>  &lt;div&gt;
    &lt;input type=&quot;file&quot; @change=&quot;handleFileChange&quot; /&gt;
    &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
export default {
  data: () =&gt; ({
    container: {
      file: null
    },
    data: []
  }),
  methods: {
    request() {},
    async handleFileChange() {},
    createFileChunk() {},
    // 上传切片，同时过滤已上传的切片
    async uploadChunks() {
      const requestList = this.data
        .map(({ chunk }) =&gt; {
          const formData = new FormData();
          formData.append(&quot;chunk&quot;, chunk);
          formData.append(&quot;hash&quot;, hash);
          formData.append(&quot;filename&quot;, this.container.file.name);
          return { formData };
        })
        .map(async ({ formData }) =&gt;
          this.request({
            url: &quot;http://localhost:3000&quot;,
            data: formData
          })
        );
      await Promise.all(requestList);
     // 合并切片
    await this.mergeRequest();
    },
   async mergeRequest() {
     await this.request({
       url: &quot;http://localhost:3000/merge&quot;,
       headers: {
         &quot;content-type&quot;: &quot;application/json&quot;
       },
       data: JSON.stringify({
         filename: this.container.file.name
       })
     });
   },    
    async handleUpload() {}
  }
};
&lt;/script&gt;```
### 服务端部分
简单使用 http 模块搭建服务端

```const http = require(&quot;http&quot;);
const server = http.createServer();

server.on(&quot;request&quot;, async (req, res) =&gt; {
  res.setHeader(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;);
  res.setHeader(&quot;Access-Control-Allow-Headers&quot;, &quot;*&quot;);
  if (req.method === &quot;OPTIONS&quot;) {
    res.status = 200;
    res.end();
    return;
  }
});

server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));
</code></pre></div><h4 id="接受切片"><a href="#接受切片" class="header-anchor">#</a> 接受切片</h4> <p>使用 multiparty 包处理前端传来的 FormData</p> <p>在 multiparty.parse 的回调中，files 参数保存了 FormData 中文件，fields 参数保存了 FormData 中非文件的字段</p> <div class="language- extra-class"><pre class="language-text"><code>const http = require(&quot;http&quot;);
const path = require(&quot;path&quot;);
const fse = require(&quot;fs-extra&quot;);
const multiparty = require(&quot;multiparty&quot;);

const server = http.createServer();
const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录

server.on(&quot;request&quot;, async (req, res) =&gt; {
  res.setHeader(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;);
  res.setHeader(&quot;Access-Control-Allow-Headers&quot;, &quot;*&quot;);
  if (req.method === &quot;OPTIONS&quot;) {
    res.status = 200;
    res.end();
    return;
  }

 const multipart = new multiparty.Form();

 multipart.parse(req, async (err, fields, files) =&gt; {
   if (err) {
     return;
   }
   const [chunk] = files.chunk;
   const [hash] = fields.hash;
   const [filename] = fields.filename;
   const chunkDir = `${UPLOAD_DIR}/${filename}`;

  // 切片目录不存在，创建切片目录
   if (!fse.existsSync(chunkDir)) {
     await fse.mkdirs(chunkDir);
   }

   // 重命名文件
   await fse.rename(chunk.path, `${chunkDir}/${hash}`);
   res.end(&quot;received file chunk&quot;);
 });
});

server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));
</code></pre></div><p>查看 multiparty 处理后的 chunk 对象，path 是存储临时文件的路径，size 是临时文件大小，在 multiparty 文档中提到可以使用 fs.rename 重命名的方式移动临时文件，也就是文件切片</p> <p>在接受文件切片时，需要先创建存储切片的文件夹，由于前端在发送每个切片时额外携带了唯一值 hash，所以以 hash 作为文件名，将切片从临时路径移动切片文件夹中，最后的结果如下</p> <h4 id="合并切片"><a href="#合并切片" class="header-anchor">#</a> 合并切片</h4> <p>在接收到前端发送的合并请求后，服务端将文件夹下的所有切片进行合并</p> <div class="language- extra-class"><pre class="language-text"><code>const http = require(&quot;http&quot;);
const path = require(&quot;path&quot;);
const fse = require(&quot;fs-extra&quot;);

const server = http.createServer();
const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录

const resolvePost = req =&gt;
  new Promise(resolve =&gt; {
    let chunk = &quot;&quot;;
    req.on(&quot;data&quot;, data =&gt; {
      chunk += data;
    });
    req.on(&quot;end&quot;, () =&gt; {
      resolve(JSON.parse(chunk));
    });
  });

// 合并切片
const mergeFileChunk = async (filePath, filename) =&gt; {
  const chunkDir = `${UPLOAD_DIR}/${filename}`;
  const chunkPaths = await fse.readdir(chunkDir);
  await fse.writeFile(filePath, &quot;&quot;);
  chunkPaths.forEach(chunkPath =&gt; {
    fse.appendFileSync(filePath, fse.readFileSync(`${chunkDir}/${chunkPath}`));
    fse.unlinkSync(`${chunkDir}/${chunkPath}`);
  });
  fse.rmdirSync(chunkDir); // 合并后删除保存切片的目录
};

server.on(&quot;request&quot;, async (req, res) =&gt; {
  res.setHeader(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;);
  res.setHeader(&quot;Access-Control-Allow-Headers&quot;, &quot;*&quot;);
  if (req.method === &quot;OPTIONS&quot;) {
    res.status = 200;
    res.end();
    return;
  }

  if (req.url === &quot;/merge&quot;) {
    const data = await resolvePost(req);
    const { filename } = data;
    const filePath = `${UPLOAD_DIR}/${filename}`;
    await mergeFileChunk(filePath, filename);
    res.end(
      JSON.stringify({
        code: 0,
        message: &quot;file merged success&quot;
      })
    );
  }

});

server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));
</code></pre></div><p>由于前端在发送合并请求时会携带文件名，服务端根据文件名可以找到上一步创建的切片文件夹</p> <p>接着使用 fs.writeFileSync 先创建一个空文件，这个空文件的文件名就是切片文件夹名 后缀名组合而成，随后通过 fs.appendFileSync 从切片文件夹中不断将切片合并到空文件中，每次合并完成后删除这个切片，等所有切片都合并完毕后最后删除切片文件夹</p> <p>至此一个简单的大文件上传就完成了，接下来我们再此基础上扩展一些额外的功能</p> <p>显示上传进度条
上传进度分两种，一个是每个切片的上传进度，另一个是整个文件的上传进度，而整个文件的上传进度是基于每个切片上传进度计算而来，所以我们先实现切片的上传进度</p> <h4 id="切片进度条"><a href="#切片进度条" class="header-anchor">#</a> 切片进度条</h4> <p>XMLHttpRequest 原生支持上传进度的监听，只需要监听 upload.onprogress 即可，我们在原来的 request 基础上传入 onProgress 参数，给 XMLHttpRequest 注册监听事件</p> <div class="language- extra-class"><pre class="language-text"><code>// xhr
   request({
     url,
     method = &quot;post&quot;,
     data,
     headers = {},
    onProgress = e =&gt; e,
     requestList
   }) {
     return new Promise(resolve =&gt; {
       const xhr = new XMLHttpRequest();
      xhr.upload.onprogress = onProgress;
       xhr.open(method, url);
       Object.keys(headers).forEach(key =&gt;
         xhr.setRequestHeader(key, headers[key])
       );
       xhr.send(data);
       xhr.onload = e =&gt; {
         resolve({
           data: e.target.response
         });
       };
     });
   }
</code></pre></div><p>由于每个切片都需要触发独立的监听事件，所以还需要一个工厂函数，根据传入的切片返回不同的监听函数</p> <p>在原先的前端上传逻辑中新增监听函数部分</p> <div class="language- extra-class"><pre class="language-text"><code>// 上传切片，同时过滤已上传的切片
    async uploadChunks(uploadedList = []) {
      const requestList = this.data
        .map(({ chunk }) =&gt; {
          const formData = new FormData();
          formData.append(&quot;chunk&quot;, chunk);
          formData.append(&quot;filename&quot;, this.container.file.name);
          return { formData };
        })
        .map(async ({ formData }) =&gt;
          this.request({
            url: &quot;http://localhost:3000&quot;,
            data: formData，
          onProgress: this.createProgressHandler(this.data[index]),
          })
        );
      await Promise.all(requestList);
       // 合并切片
      await this.mergeRequest();
    },
    async handleUpload() {
      if (!this.container.file) return;
      const fileChunkList = this.createFileChunk(this.container.file);
      this.data = fileChunkList.map(({ file }，index) =&gt; ({
        chunk: file,
      index,
        hash: this.container.file.name &quot;-&quot; index
      percentage:0
      }));
      await this.uploadChunks();
    }    
  createProgressHandler(item) {
     return e =&gt; {
       item.percentage = parseInt(String((e.loaded / e.total) * 100));
     };
   }
</code></pre></div><p>每个切片在上传时都会通过监听函数更新 data 数组对应元素的 percentage 属性，之后把将 data 数组放到视图中展示即可</p> <p>文件进度条
将每个切片已上传的部分累加，除以整个文件的大小，就能得出当前文件的上传进度，所以这里使用 Vue 计算属性</p> <div class="language- extra-class"><pre class="language-text"><code>computed: {
       uploadPercentage() {
          if (!this.container.file || !this.data.length) return 0;
          const loaded = this.data
            .map(item =&gt; item.size * item.percentage)
            .reduce((acc, cur) =&gt; acc cur);
          return parseInt((loaded / this.container.file.size).toFixed(2));
        }
 }
</code></pre></div><h3 id="最终视图如下"><a href="#最终视图如下" class="header-anchor">#</a> 最终视图如下</h3> <h4 id="断点续传"><a href="#断点续传" class="header-anchor">#</a> 断点续传</h4> <p>断点续传的原理在于前端/服务端需要记住已上传的切片，这样下次上传就可以跳过之前已上传的部分，有两种方案实现记忆的功能</p> <h4 id="前端使用-localstorage-记录已上传的切片-hash"><a href="#前端使用-localstorage-记录已上传的切片-hash" class="header-anchor">#</a> 前端使用 localStorage 记录已上传的切片 hash</h4> <p>服务端保存已上传的切片 hash，前端每次上传前向服务端获取已上传的切片
第一种是前端的解决方案，第二种是服务端，而前端方案有一个缺陷，如果换了个浏览器就失去了记忆的效果，所以这里选取后者</p> <h4 id="生成-hash"><a href="#生成-hash" class="header-anchor">#</a> 生成 hash</h4> <p>无论是前端还是服务端，都必须要生成文件和切片的 hash，之前我们使用文件名 切片下标作为切片 hash，这样做文件名一旦修改就失去了效果，而事实上只要文件内容不变，hash 就不应该变化，所以正确的做法是根据文件内容生成 hash，所以我们修改一下 hash 的生成规则</p> <p>这里用到另一个库 spark-md5，它可以根据文件内容计算出文件的 hash 值，另外考虑到如果上传一个超大文件，读取文件内容计算 hash 是非常耗费时间的，并且会引起 UI 的阻塞，导致页面假死状态，所以我们使用 web-worker 在 worker 线程计算 hash，这样用户仍可以在主界面正常的交互</p> <p>由于实例化 web-worker 时，参数是一个 js 文件路径且不能跨域，所以我们单独创建一个 hash.js 文件放在 public 目录下，另外在 worker 中也是不允许访问 dom 的，但它提供了importScripts 函数用于导入外部脚本，通过它导入 spark-md5</p> <div class="language- extra-class"><pre class="language-text"><code>// /public/hash.js
self.importScripts(&quot;/spark-md5.min.js&quot;); // 导入脚本

// 生成文件 hash
self.onmessage = e =&gt; {
  const { fileChunkList } = e.data;
  const spark = new self.SparkMD5.ArrayBuffer();
  let percentage = 0;
  let count = 0;
  const loadNext = index =&gt; {
    const reader = new FileReader();
    reader.readAsArrayBuffer(fileChunkList[index].file);
    reader.onload = e =&gt; {
      count++;
      spark.append(e.target.result);
      if (count === fileChunkList.length) {
        self.postMessage({
          percentage: 100,
          hash: spark.end()
        });
        self.close();
      } else {
        percentage += 100 / fileChunkList.length;
        self.postMessage({
          percentage
        });
        // 递归计算下一个切片
        loadNext(count);
      }
    };
  };
  loadNext(0);
};
</code></pre></div><p>在 worker 线程中，接受文件切片 fileChunkList，利用 FileReader 读取每个切片的 ArrayBuffer 并不断传入 spark-md5 中，每计算完一个切片通过 postMessage 向主线程发送一个进度事件，全部完成后将最终的 hash 发送给主线程</p> <p>spark-md5 需要根据所有切片才能算出一个 hash 值，不能直接将整个文件放入计算，否则即使不同文件也会有相同的 hash，具体可以看官方文档</p> <p>spark-md5</p> <p>接着编写主线程与 worker 线程通讯的逻辑</p> <div class="language- extra-class"><pre class="language-text"><code>//生成文件 hash（web-worker）
   calculateHash(fileChunkList) {
     return new Promise(resolve =&gt; {
      // 添加 worker 属性
       this.container.worker = new Worker(&quot;/hash.js&quot;);
       this.container.worker.postMessage({ fileChunkList });
       this.container.worker.onmessage = e =&gt; {
         const { percentage, hash } = e.data;
         this.hashPercentage = percentage;
         if (hash) {
           resolve(hash);
         }
       };
     });
    },
    async handleUpload() {
      if (!this.container.file) return;
      const fileChunkList = this.createFileChunk(this.container.file);
    this.container.hash = await this.calculateHash(fileChunkList);
      this.data = fileChunkList.map(({ file }，index) =&gt; ({
      fileHash: this.container.hash,
        chunk: file,
        hash: this.container.file.name &quot;-&quot; index, // 文件名 数组下标
        percentage:0
      }));
      await this.uploadChunks();
    }  
</code></pre></div><p>主线程使用 postMessage 给 worker 线程传入所有切片 fileChunkList，并监听 worker 线程发出的 postMessage 事件拿到文件 hash</p> <p>加上显示计算 hash 的进度条，看起来像这样</p> <p>至此前端需要将之前用文件名作为 hash 的地方改写为 workder 返回的这个 hash</p> <p>服务端则使用 hash 作为切片文件夹名，hash 下标作为切片名，hash 扩展名作为文件名，没有新增的逻辑</p> <h3 id="文件秒传"><a href="#文件秒传" class="header-anchor">#</a> 文件秒传</h3> <p>在实现断点续传前先简单介绍一下文件秒传</p> <p>所谓的文件秒传，即在服务端已经存在了上传的资源，所以当用户再次上传时会直接提示上传成功</p> <p>文件秒传需要依赖上一步生成的 hash，即在上传前，先计算出文件 hash，并把 hash 发送给服务端进行验证，由于 hash 的唯一性，所以一旦服务端能找到 hash 相同的文件，则直接返回上传成功的信息即可</p> <div class="language- extra-class"><pre class="language-text"><code> async verifyUpload(filename, fileHash) {
      const { data } = await this.request({
        url: &quot;http://localhost:3000/verify&quot;,
        headers: {
          &quot;content-type&quot;: &quot;application/json&quot;
        },
        data: JSON.stringify({
          filename,
          fileHash
        })
      });
      return JSON.parse(data);
    },
   async handleUpload() {
      if (!this.container.file) return;
      const fileChunkList = this.createFileChunk(this.container.file);
      this.container.hash = await this.calculateHash(fileChunkList);
    const { shouldUpload } = await this.verifyUpload(
      this.container.file.name,
      this.container.hash
    );
    if (!shouldUpload) {
      this.$message.success(&quot;秒传：上传成功&quot;);
      return;
   }
     this.data = fileChunkList.map(({ file }, index) =&gt; ({
        fileHash: this.container.hash,
        index,
        hash: this.container.hash &quot;-&quot; index,
        chunk: file,
        percentage: 0
      }));
      await this.uploadChunks();
    }   
</code></pre></div><p>秒传其实就是给用户看的障眼法，实质上根本没有上传</p> <p>服务端的逻辑非常简单，新增一个验证接口，验证文件是否存在即可</p> <div class="language- extra-class"><pre class="language-text"><code>const extractExt = filename =&gt;
 filename.slice(filename.lastIndexOf(&quot;.&quot;), filename.length); // 提取后缀名
const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录

const resolvePost = req =&gt;
  new Promise(resolve =&gt; {
    let chunk = &quot;&quot;;
    req.on(&quot;data&quot;, data =&gt; {
      chunk += data;
    });
    req.on(&quot;end&quot;, () =&gt; {
      resolve(JSON.parse(chunk));
    });
  });

server.on(&quot;request&quot;, async (req, res) =&gt; {
  if (req.url === &quot;/verify&quot;) {
   const data = await resolvePost(req);
   const { fileHash, filename } = data;
   const ext = extractExt(filename);
   const filePath = `${UPLOAD_DIR}/${fileHash}${ext}`;
   if (fse.existsSync(filePath)) {
     res.end(
       JSON.stringify({
         shouldUpload: false
       })
     );
   } else {
     res.end(
       JSON.stringify({
         shouldUpload: true
       })
     );
   }
  }
});
server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));
</code></pre></div><p>暂停上传
讲完了生成 hash 和文件秒传，回到断点续传</p> <p>断点续传顾名思义即断点 续传，所以我们第一步先实现“断点”，也就是暂停上传</p> <p>原理是使用 XMLHttpRequest 的 abort 方法，可以取消一个 xhr 请求的发送，为此我们需要将上传每个切片的 xhr 对象保存起来，我们再改造一下 request 方法</p> <div class="language- extra-class"><pre class="language-text"><code>request({
   url,
   method = &quot;post&quot;,
   data,
   headers = {},
   onProgress = e =&gt; e,
 requestList
 }) {
   return new Promise(resolve =&gt; {
     const xhr = new XMLHttpRequest();
     xhr.upload.onprogress = onProgress;
     xhr.open(method, url);
     Object.keys(headers).forEach(key =&gt;
       xhr.setRequestHeader(key, headers[key])
     );
     xhr.send(data);
     xhr.onload = e =&gt; {
      // 将请求成功的 xhr 从列表中删除
      if (requestList) {
        const xhrIndex = requestList.findIndex(item =&gt; item === xhr);
        requestList.splice(xhrIndex, 1);
      }
       resolve({
         data: e.target.response
       });
     };
    // 暴露当前 xhr 给外部
    requestList?.push(xhr);
   });
 },
</code></pre></div><p>这样在上传切片时传入 requestList 数组作为参数，request 方法就会将所有的 xhr 保存在数组中了</p> <p>每当一个切片上传成功时，将对应的 xhr 从 requestList 中删除，所以 requestList 中只保存正在上传切片的 xhr</p> <p>之后新建一个暂停按钮，当点击按钮时，调用保存在 requestList 中 xhr 的 abort 方法，即取消并清空所有正在上传的切片</p> <div class="language- extra-class"><pre class="language-text"><code>handlePause() {
    this.requestList.forEach(xhr =&gt; xhr?.abort());
    this.requestList = [];
}
</code></pre></div><p>点击暂停按钮可以看到 xhr 都被取消了</p> <p>恢复上传
之前在介绍断点续传的时提到使用第二种服务端存储的方式实现续传</p> <p>由于当文件切片上传后，服务端会建立一个文件夹存储所有上传的切片，所以每次前端上传前可以调用一个接口，服务端将已上传的切片的切片名返回，前端再跳过这些已经上传切片，这样就实现了“续传”的效果</p> <p>而这个接口可以和之前秒传的验证接口合并，前端每次上传前发送一个验证的请求，返回两种结果</p> <p>服务端已存在该文件，不需要再次上传
服务端不存在该文件或者已上传部分文件切片，通知前端进行上传，并把已上传的文件切片返回给前端
所以我们改造一下之前文件秒传的服务端验证接口</p> <div class="language- extra-class"><pre class="language-text"><code>const extractExt = filename =&gt;
  filename.slice(filename.lastIndexOf(&quot;.&quot;), filename.length); // 提取后缀名
const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录

const resolvePost = req =&gt;
  new Promise(resolve =&gt; {
    let chunk = &quot;&quot;;
    req.on(&quot;data&quot;, data =&gt; {
      chunk += data;
    });
    req.on(&quot;end&quot;, () =&gt; {
      resolve(JSON.parse(chunk));
    });
  });

 // 返回已经上传切片名列表
const createUploadedList = async fileHash =&gt;
  fse.existsSync(`${UPLOAD_DIR}/${fileHash}`)
   ? await fse.readdir(`${UPLOAD_DIR}/${fileHash}`)
   : [];

server.on(&quot;request&quot;, async (req, res) =&gt; {
  if (req.url === &quot;/verify&quot;) {
    const data = await resolvePost(req);
    const { fileHash, filename } = data;
    const ext = extractExt(filename);
    const filePath = `${UPLOAD_DIR}/${fileHash}${ext}`;
    if (fse.existsSync(filePath)) {
      res.end(
        JSON.stringify({
          shouldUpload: false
        })
      );
    } else {
      res.end(
        JSON.stringify({
          shouldUpload: true，
        uploadedList: await createUploadedList(fileHash)
        })
      );
    }
  }
});
server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));
</code></pre></div><p>接着回到前端，前端有两个地方需要调用验证的接口</p> <p>点击上传时，检查是否需要上传和已上传的切片
点击暂停后的恢复上传，返回已上传的切片
新增恢复按钮并改造原来上传切片的逻辑</p> <div class="language- extra-class"><pre class="language-text"><code>&lt;template&gt;
  &lt;div id=&quot;app&quot;&gt;
      &lt;input
        type=&quot;file&quot;
        @change=&quot;handleFileChange&quot;
      /&gt;
       &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt;
       &lt;el-button @click=&quot;handlePause&quot; v-if=&quot;isPaused&quot;&gt;暂停&lt;/el-button&gt;
     &lt;el-button @click=&quot;handleResume&quot; v-else&gt;恢复&lt;/el-button&gt;
      //...
    &lt;/div&gt;
&lt;/template&gt;

  async handleResume() {
     const { uploadedList } = await this.verifyUpload(
       this.container.file.name,
       this.container.hash
     );
     await this.uploadChunks(uploadedList);
    },
    async handleUpload() {
      if (!this.container.file) return;
      const fileChunkList = this.createFileChunk(this.container.file);
      this.container.hash = await this.calculateHash(fileChunkList);

    const { shouldUpload, uploadedList } = await this.verifyUpload(
        this.container.file.name,
        this.container.hash
      );
      if (!shouldUpload) {
        this.$message.success(&quot;秒传：上传成功&quot;);
        return;
      }

      this.data = fileChunkList.map(({ file }, index) =&gt; ({
        fileHash: this.container.hash,
        index,
        hash: this.container.hash &quot;-&quot; index,
        chunk: file，
        percentage: 0
      }));

     await this.uploadChunks(uploadedList);
    },
   // 上传切片，同时过滤已上传的切片
  async uploadChunks(uploadedList = []) {
      const requestList = this.data
       .filter(({ hash }) =&gt; !uploadedList.includes(hash))
        .map(({ chunk, hash, index }) =&gt; {
          const formData = new FormData();
          formData.append(&quot;chunk&quot;, chunk);
          formData.append(&quot;hash&quot;, hash);
          formData.append(&quot;filename&quot;, this.container.file.name);
          formData.append(&quot;fileHash&quot;, this.container.hash);
          return { formData, index };
        })
        .map(async ({ formData, index }) =&gt;
          this.request({
            url: &quot;http://localhost:3000&quot;,
            data: formData,
            onProgress: this.createProgressHandler(this.data[index]),
            requestList: this.requestList
          })
        );
      await Promise.all(requestList);
      // 之前上传的切片数量 本次上传的切片数量 = 所有切片数量时
      // 合并切片
     if (uploadedList.length requestList.length === this.data.length) {
         await this.mergeRequest();
     }
    }
</code></pre></div><p>这里给原来上传切片的函数新增 uploadedList 参数，即上图中服务端返回的切片名列表，通过 filter 过滤掉已上传的切片，并且由于新增了已上传的部分，所以之前合并接口的触发条件做了一些改动</p> <p>到这里断点续传的功能基本完成了</p> <p>进度条改进
虽然实现了断点续传，但还需要修改一下进度条的显示规则，否则在暂停上传/接收到已上传切片时的进度条会出现偏差</p> <p>切片进度条
由于在点击上传/恢复上传时，会调用验证接口返回已上传的切片，所以需要将已上传切片的进度变成 100%</p> <div class="language- extra-class"><pre class="language-text"><code>async handleUpload() {
      if (!this.container.file) return;
      const fileChunkList = this.createFileChunk(this.container.file);
      this.container.hash = await this.calculateHash(fileChunkList);
      const { shouldUpload, uploadedList } = await this.verifyUpload(
        this.container.file.name,
        this.container.hash
      );
      if (!shouldUpload) {
        this.$message.success(&quot;秒传：上传成功&quot;);
        return;
      }
      this.data = fileChunkList.map(({ file }, index) =&gt; ({
        fileHash: this.container.hash,
        index,
        hash: this.container.hash &quot;-&quot; index,
        chunk: file,
      percentage: uploadedList.includes(index) ? 100 : 0
      }));
      await this.uploadChunks(uploadedList);
    },
</code></pre></div><p>uploadedList 会返回已上传的切片，在遍历所有切片时判断当前切片是否在已上传列表里即可</p> <p>文件进度条
之前说到文件进度条是一个计算属性，根据所有切片的上传进度计算而来，这就遇到了一个问题</p> <p>点击暂停会取消并清空切片的 xhr 请求，此时如果已经上传了一部分，就会发现文件进度条有倒退的现象</p> <p>当点击恢复时，由于重新创建了 xhr 导致切片进度清零，所以总进度条就会倒退</p> <p>此时需要创建一个“假”的进度条，这个假进度条基于文件进度条，但只会停止和增加，然后给用户展示这个假的进度条</p> <p>这里我们使用 Vue 的监听属性</p> <div class="language- extra-class"><pre class="language-text"><code>data: () =&gt; ({
 fakeUploadPercentage: 0
}),
computed: {
  uploadPercentage() {
    if (!this.container.file || !this.data.length) return 0;
    const loaded = this.data
      .map(item =&gt; item.size * item.percentage)
      .reduce((acc, cur) =&gt; acc cur);
    return parseInt((loaded / this.container.file.size).toFixed(2));
  }
},  
watch: {
 uploadPercentage(now) {
   if (now &gt; this.fakeUploadPercentage) {
     this.fakeUploadPercentage = now;
   }
  }
},
</code></pre></div><p>当 uploadPercentage 即真的文件进度条增加时，fakeUploadPercentage 也增加，一旦文件进度条后退，假的进度条只需停止即可</p> <p>至此一个大文件上传 断点续传的解决方案就完成了</p> <h3 id="总结"><a href="#总结" class="header-anchor">#</a> 总结</h3> <h4 id="大文件上传"><a href="#大文件上传" class="header-anchor">#</a> 大文件上传</h4> <p>前端上传大文件时使用 Blob.prototype.slice 将文件切片，并发上传多个切片，最后发送一个合并的请求通知服务端合并切片
服务端接收切片并存储，收到合并请求后使用 fs.appendFileSync 对多个切片进行合并
原生 XMLHttpRequest 的 upload.onprogress 对切片上传进度的监听
使用 Vue 计算属性根据每个切片的进度算出整个文件的上传进度</p> <h4 id="断点续传-2"><a href="#断点续传-2" class="header-anchor">#</a> 断点续传</h4> <p>使用 spart-md5 根据文件内容算出文件 hash
通过 hash 可以判断服务端是否已经上传该文件，从而直接提示用户上传成功（秒传）
通过 XMLHttpRequest 的 abort 方法暂停切片的上传
上传前服务端返回已经上传的切片名，前端跳过这些切片的上传</p> <h4 id="源代码"><a href="#源代码" class="header-anchor">#</a> 源代码</h4> <p>源代码增加了一些按钮的状态，交互更加友好，文章表达比较晦涩的地方可以跳转到源代码查看</p> <p>file-upload</p> <p>谢谢观看 😃</p></div></div></div> <div data-v-9fc62fe0><div class="article_copyright" data-v-089751b8 data-v-9fc62fe0><div class="article_footer" data-v-089751b8><div class="content" data-v-089751b8><blockquote data-v-089751b8><p data-v-089751b8>博客内容遵循 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 协议</p> <p data-v-089751b8>本文永久链接是：<a href="/2020/07/31/%E5%AE%9E%E7%8E%B0%E4%B8%80%E4%B8%AA%E5%A4%A7%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0%E5%92%8C%E6%96%AD%E7%82%B9%E7%BB%AD%E4%BC%A0/" style="display: block;" data-v-089751b8>/2020/07/31/%E5%AE%9E%E7%8E%B0%E4%B8%80%E4%B8%AA%E5%A4%A7%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0%E5%92%8C%E6%96%AD%E7%82%B9%E7%BB%AD%E4%BC%A0/</a></p></blockquote></div></div> <div class="new-meta-box" data-v-089751b8><div itemprop="dateUpdated" class="new-date" data-v-089751b8><a data-v-089751b8><i aria-hidden="true" class="fa fa-edit fa-fw" data-v-089751b8></i> <p data-v-089751b8>更新于：2020-07-31T20:42:22.000Z</p></a></div></div> <div class="prev-next" data-v-089751b8><a href="/2020/07/30/%E5%A6%82%E4%BD%95%E5%B0%86%E7%8C%AB%E7%8C%AB%E7%9B%91%E6%8E%A7%E6%94%BE%E5%9C%A8%E5%8D%9A%E5%AE%A2%E4%B8%8A/" class="prev" data-v-089751b8><p class="title" data-v-089751b8><i aria-hidden="true" class="fa fa-chevron-left" data-v-089751b8></i>如何将猫猫监控放在博客上</p></a> <a href="/2020/08/28/%E6%8E%A8%E8%8D%90%E4%B8%80%E4%B8%AAcss%E6%8A%96%E5%8A%A8%E5%BA%93/" class="next" data-v-089751b8><p class="title" data-v-089751b8>推荐一个css抖动库<i aria-hidden="true" class="fa fa-chevron-right" data-v-089751b8></i></p></a></div></div></div></div> <div class="article-comment" data-v-9fc62fe0><div class="comment" data-v-2ce08322 data-v-9fc62fe0><div class="post-list" data-v-2ce08322><div class="post-wrapper vcomment-div" data-v-2ce08322><em class="post-meta-item-text" data-v-2ce08322>阅读量 </em> <i class="leancloud-visitors-count" data-v-2ce08322>1000000</i> <div data-v-2ce08322></div></div></div></div></div></div></div></div> <div class="right-page" data-v-bb8ac3a0><div class="rightPage" data-v-1be37983 data-v-bb8ac3a0><div class="computer" data-v-1be37983 data-v-1be37983><div class="content paperMenu" style="transition:0s;" data-v-1be37983><div class="social-wrapper paper-navigation" data-v-1be37983><div data-v-1be37983><i class="fas fa-folder-open fa-fw" data-v-1be37983></i> <span data-v-1be37983>文章导航</span></div> <div class="navigationContent" data-v-1be37983><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="本文将从零搭建前端和服务端-实现一个大文件上传和断点续传的-demo" data-v-1be37983></a>本文将从零搭建前端和服务端，实现一个大文件上传和断点续传的 demo
                            </h4> <!----> <!----></div><div data-v-1be37983><!----> <h2 class="paperlevel" data-v-1be37983><a id="前言" data-v-1be37983></a>前言
                            </h2> <!----> <!----> <!----> <!----></div><div data-v-1be37983><!----> <h2 class="paperlevel" data-v-1be37983><a id="整体思路" data-v-1be37983></a>整体思路
                            </h2> <!----> <!----> <!----> <!----></div><div data-v-1be37983><!----> <!----> <h3 class="paperlevel" data-v-1be37983><a id="前端" data-v-1be37983></a>前端
                            </h3> <!----> <!----> <!----></div><div data-v-1be37983><!----> <!----> <h3 class="paperlevel" data-v-1be37983><a id="服务端" data-v-1be37983></a>服务端
                            </h3> <!----> <!----> <!----></div><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="何时合并切片-即切片什么时候传输完成-如何合并切片" data-v-1be37983></a>何时合并切片，即切片什么时候传输完成？如何合并切片？
                            </h4> <!----> <!----></div><div data-v-1be37983><!----> <!----> <h3 class="paperlevel" data-v-1be37983><a id="前端部分" data-v-1be37983></a>前端部分
                            </h3> <!----> <!----> <!----></div><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="上传控件" data-v-1be37983></a>上传控件
                            </h4> <!----> <!----></div><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="请求逻辑" data-v-1be37983></a>请求逻辑
                            </h4> <!----> <!----></div><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="上传切片" data-v-1be37983></a>上传切片
                            </h4> <!----> <!----></div><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="对文件进行切片" data-v-1be37983></a>对文件进行切片
                            </h4> <!----> <!----></div><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="发送合并请求" data-v-1be37983></a>发送合并请求
                            </h4> <!----> <!----></div><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="接受切片" data-v-1be37983></a>接受切片
                            </h4> <!----> <!----></div><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="合并切片" data-v-1be37983></a>合并切片
                            </h4> <!----> <!----></div><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="切片进度条" data-v-1be37983></a>切片进度条
                            </h4> <!----> <!----></div><div data-v-1be37983><!----> <!----> <h3 class="paperlevel" data-v-1be37983><a id="最终视图如下" data-v-1be37983></a>最终视图如下
                            </h3> <!----> <!----> <!----></div><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="断点续传" data-v-1be37983></a>断点续传
                            </h4> <!----> <!----></div><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="前端使用-localstorage-记录已上传的切片-hash" data-v-1be37983></a>前端使用 localStorage 记录已上传的切片 hash
                            </h4> <!----> <!----></div><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="生成-hash" data-v-1be37983></a>生成 hash
                            </h4> <!----> <!----></div><div data-v-1be37983><!----> <!----> <h3 class="paperlevel" data-v-1be37983><a id="文件秒传" data-v-1be37983></a>文件秒传
                            </h3> <!----> <!----> <!----></div><div data-v-1be37983><!----> <!----> <h3 class="paperlevel" data-v-1be37983><a id="总结" data-v-1be37983></a>总结
                            </h3> <!----> <!----> <!----></div><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="大文件上传" data-v-1be37983></a>大文件上传
                            </h4> <!----> <!----></div><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="断点续传-2" data-v-1be37983></a>断点续传
                            </h4> <!----> <!----></div><div data-v-1be37983><!----> <!----> <!----> <h4 class="paperlevel" data-v-1be37983><a id="源代码" data-v-1be37983></a>源代码
                            </h4> <!----> <!----></div></div></div></div> <div class="content" data-v-1be37983><div class="head" data-v-1be37983><a href="/aboutPage/" class="avatar flat-box rectangle" data-v-1be37983><img src="https://blog.jingzhe.xyz/jingzhe/jpg/1593256062295.jpeg" data-v-1be37983></a> <p data-v-1be37983><span data-v-1be37983>微雨众卉新，一雷惊蛰始。</span></p></div> <div class="social-wrapper" data-v-1be37983><span data-v-1be37983><a href="" rel="external nofollow noopener noreferrer" target="_blank" class="social fab fa-github flat-btn" data-v-1be37983></a></span> <span data-v-1be37983><a href="mailto: 12363078673@qq.com" rel="external nofollow noopener noreferrer" target="_blank" class="social fas fa-envelope flat-btn" data-v-1be37983></a></span> <span data-v-1be37983><a href="https://blog.csdn.net/u013605060" rel="external nofollow noopener noreferrer" target="_blank" class="social fas fa-code flat-btn" data-v-1be37983></a></span> <span data-v-1be37983><a href="http://wpa.qq.com/msgrd?v=3&amp;uin=1263078673&amp;site=qq&amp;menu=yes" rel="external nofollow noopener noreferrer" target="_blank" class="social fa fa-qq flat-btn" data-v-1be37983></a></span></div></div> <div class="content" data-v-1be37983><div class="social-wrapper" data-v-1be37983><div data-v-1be37983><i class="fas fa-folder-open fa-fw" data-v-1be37983></i> <span data-v-1be37983>文章分类</span></div> <div data-v-1be37983><ul class="entry navigation" data-v-1be37983><li data-v-1be37983><a href="/categories/笔记/" id="blogcategoriesmac" class="flat-box" data-v-1be37983><div class="name" data-v-1be37983>笔记</div> <div class="badge" data-v-1be37983>(14)</div></a></li><li data-v-1be37983><a href="/categories/作品集/" id="blogcategoriesmac" class="flat-box" data-v-1be37983><div class="name" data-v-1be37983>作品集</div> <div class="badge" data-v-1be37983>(1)</div></a></li><li data-v-1be37983><a href="/categories/随笔/" id="blogcategoriesmac" class="flat-box" data-v-1be37983><div class="name" data-v-1be37983>随笔</div> <div class="badge" data-v-1be37983>(1)</div></a></li><li data-v-1be37983><a href="/categories/喵喵/" id="blogcategoriesmac" class="flat-box" data-v-1be37983><div class="name" data-v-1be37983>喵喵</div> <div class="badge" data-v-1be37983>(2)</div></a></li><li data-v-1be37983><a href="/categories/创作集/" id="blogcategoriesmac" class="flat-box" data-v-1be37983><div class="name" data-v-1be37983>创作集</div> <div class="badge" data-v-1be37983>(1)</div></a></li></ul></div></div></div> <div class="content" data-v-1be37983><div class="social-wrapper" data-v-1be37983><div data-v-1be37983><i class="fas fa-tags fa-fw" data-v-1be37983></i> <span data-v-1be37983>热门标签</span></div> <div class="word-cloud" data-v-1be37983><a href="/tags/jenkins/" style="font-size:15px;" data-v-1be37983>jenkins</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/vue/" style="font-size:18px;" data-v-1be37983>vue</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/JS/" style="font-size:24px;" data-v-1be37983>JS</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/JAVA/" style="font-size:15px;" data-v-1be37983>JAVA</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/Trilateration/" style="font-size:15px;" data-v-1be37983>Trilateration</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/Google/" style="font-size:15px;" data-v-1be37983>Google</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/pjax/" style="font-size:15px;" data-v-1be37983>pjax</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/%E7%94%B5%E5%BD%B1/" style="font-size:15px;" data-v-1be37983>电影</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/%E5%85%AB%E4%BD%B0/" style="font-size:15px;" data-v-1be37983>八佰</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/%E5%89%8D%E7%AB%AF/" style="font-size:18px;" data-v-1be37983>前端</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/css/" style="font-size:18px;" data-v-1be37983>css</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/docker/" style="font-size:15px;" data-v-1be37983>docker</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/nginx/" style="font-size:18px;" data-v-1be37983>nginx</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/rtmp/" style="font-size:15px;" data-v-1be37983>rtmp</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/ffmpeg/" style="font-size:18px;" data-v-1be37983>ffmpeg</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/element-ui/" style="font-size:15px;" data-v-1be37983>element-ui</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/nodejs/" style="font-size:15px;" data-v-1be37983>nodejs</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/shake/" style="font-size:15px;" data-v-1be37983>shake</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/DoraemonKit/" style="font-size:15px;" data-v-1be37983>DoraemonKit</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/%E6%BB%B4%E6%BB%B4/" style="font-size:15px;" data-v-1be37983>滴滴</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/BIO/NIO/AIO/" style="font-size:15px;" data-v-1be37983>BIO/NIO/AIO</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/%E6%8F%92%E4%BB%B6/" style="font-size:15px;" data-v-1be37983>插件</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/HTTP/HTTPS/" style="font-size:15px;" data-v-1be37983>HTTP/HTTPS</a></div><div class="word-cloud" data-v-1be37983><a href="/tags/TCPIP/" style="font-size:15px;" data-v-1be37983>TCPIP</a></div></div></div> <div class="content" data-v-1be37983><div class="social-wrapper" data-v-1be37983><div data-v-1be37983><i class="fas fa-bullhorn fa-fw" data-v-1be37983></i> <span data-v-1be37983>公告</span></div> <div data-v-1be37983>
                        本站使用VuePress创建全站，还在不断完善中
                    </div></div></div> <div class="content" data-v-1be37983><div class="social-wrapper" data-v-1be37983><div data-v-1be37983><i class="fas fa-award fa-fw" data-v-1be37983></i> <span data-v-1be37983>站点信息</span></div> <div class="webinfo" data-v-1be37983><div class="webinfo-item" data-v-1be37983><div data-v-1be37983>文章数目：</div> <div data-v-1be37983>58 篇</div></div> <div class="webinfo-item" data-v-1be37983><div data-v-1be37983>已运行时间：</div> <div id="webinfo-runtime-count" data-v-1be37983>162 天</div></div> <div class="webinfo-item" data-v-1be37983><div data-v-1be37983>更新时间时间：</div> <div id="webinfo-runtime-count" data-v-1be37983>3 天前更新</div></div></div></div></div></div></div></div></div></div></div></div><div class="global-ui"></div></div>
<script src="/assets/js/app.8e64649f.js" defer></script><script src="/assets/js/17.9b1a1e45.js" defer></script><script src="/assets/js/4.84c4889c.js" defer></script><script src="/assets/js/32.b87582bb.js" defer></script>
<!-- 加入音乐-->
<div style="max-width: 750px;margin: 0 auto;padding: 40px;display: none;" id="meetingJs">
    <meting-js server="netease" type="playlist" id="567087780" list-max-height=150px></meting-js>
</div>
<!--全屏遮挡-->
<div class="fullScreen" style=""></div>

<canvas width="1413" height="968"
        style="position: fixed; left: 0px; top: 0px; z-index: 2147483647; pointer-events: none;"></canvas>
<!--鼠标特效-->
<script src="https://cdn.jsdelivr.net/gh/zyoushuo/Blog/hexo/js/mouse_click.js"></script>
<script>
    // 写一个简单的pjax，适配于vuepress
    $(function () {
        function pjax(href, type) {
            //为了seo的优化，不支持方法直接跳转
            if (!history.pushState) {
                window.location.href = href;
            }
            let newHref = href;
            $.ajax({
                url: newHref, success: function (result) {
                    let resultObj = $(result);
                    //获取标题
                    let newTitle = resultObj.filter("title").text();
                    //循环Script标签，重新加载拥有defer的
                    $("script[defer]").remove();
                    $(result).filter('script[defer]').each(function () {
                        $("body").append(this.outerHTML);
                    })
                    $("#main-body-pjax").after("<div style='display: none;' id='main-body-pjax-other' class='"+$("#main-body-pjax").attr('class')+"'></div>");
                    $("#main-body-pjax-other")[0].innerHTML = resultObj.find("div[id=main-body-pjax]").innerHTML;
                    let state = ({
                        url: newHref, title: newTitle
                    });
                    if (type === 1) {
                        window.history.pushState(state, newTitle, newHref);
                    }
                    document.title = newTitle
                    //滚回顶部
                    // $('html, body').animate({scrollTop: 0}, 'slow');
                    //背景再次被重置
                    if (window.localStorage.getItem('cutoverColor') === "false") {
                        $(".fullScreen").css("background","#000")
                    }
                }
            });
        }

        // 手写一个简易版本的pjax，适用于vuepress
        $('body').on('click', 'a', function (e) {
            e.preventDefault();
            if (typeof $(this).attr("href") != "undefined") {
                pjax($(this).attr("href"), 1);
            }
        })
        window.addEventListener('popstate', function (evt) {
            let oldHref = evt.state.url;
            pjax(oldHref, 0);
        }, false);
    })


    window.onload = function () {
        //防止哔哔哔模块不显示
        bbtalk.init({
            appId: "gbNUwY2ePs3khwpWCJvSNMC5-MdYXbMMI",
            appKey: "ObQ0ANDmaRwvTREmMpzN4WNv",
            serverURLs: 'https://gbnuwy2e.api.lncldglobal.com'
        })
        //音乐模块显示
        $("#meetingJs").show();
        //背景切换图
        $.backstretch(
            ["https://cdn.jsdelivr.net/gh/volantis-x/cdn-wallpaper-minimalist/2020/006.jpg",
                "https://cdn.jsdelivr.net/gh/volantis-x/cdn-wallpaper-minimalist/2020/051.jpg",
                "https://cdn.jsdelivr.net/gh/volantis-x/cdn-wallpaper-minimalist/2020/034.jpg",
                "https://cdn.jsdelivr.net/gh/volantis-x/cdn-wallpaper-minimalist/2020/025.jpg",
                "https://api.mtyqx.cn/api/random.php",
            ], {
                fade: 750, duration: 4000, speed: 500
            }
        );
        //头部，返回到顶部展示，右侧菜单的展示规则
        let t = document.documentElement.scrollTop || document.body.scrollTop;
        headShow(t);
        backTopShow(t);
        rightPageMenuShow(t);
        //事件委托 (触发跳转)文章导航
        try {
            $("body").on('click', '.navigationContent', function (event) {
                let id = event.target.getElementsByTagName('a')[0].id;
                scroll(0, document.getElementById(id).offsetTop - 85);
            })
        } catch (e) {
        }
    }

    //移动滚动条
    function scroll(currentY, targetY) {
        // 计算需要移动的距离
        let needScrollTop = targetY - currentY
        let _currentY = currentY
        setTimeout(() => {
            // 一次调用滑动帧数，每次调用会不一样
            const dist = Math.ceil(needScrollTop / 10)
            _currentY += dist
            window.scrollTo(_currentY, currentY)
            // 如果移动幅度小于十个像素，直接移动，否则递归调用，实现动画效果
            if (needScrollTop > 10 || needScrollTop < -10) {
                scroll(_currentY, targetY)
            } else {
                window.scrollTo(_currentY, targetY)
            }
        }, 1)
    }

    /*监听滚动条，显示或者隐藏某些元素（右侧文章锚点和头部）*/
    window.onscroll = function () {
        let t = document.documentElement.scrollTop || document.body.scrollTop;
        headShow(t);
        backTopShow(t);
        rightPageMenuShow(t);
    }

    function headShow(t) {
        let container = document.querySelector('.container');//container 显示
        let height = (document.querySelector('.homeCover').offsetHeight) -
            (document.querySelector('.container').style.height);
        container.style.display = (t >= (height - 50)) ? "block" : "none";
    }

    function backTopShow(t) {
        try {
            let backTop = document.querySelector('.footer-up');//footer-up 显示
            backTop.style.display = (t >= 30) ? "block" : "none";
        } catch (e) {
        }
    }

    function rightPageMenuShow(t) {
        try {
            let rightPageMenu = document.querySelector('.paperMenu');//footer-up 显示
            rightPageMenu.className = (t >= 10) ? "content paperMenu suspension" : "content paperMenu";
        } catch (e) {
        }
    }


</script>


<script>
    //暗黑模式
    let cutoverColor = false;
    $(function () {
        if (window.localStorage.getItem('cutoverColor') === "false") {
            //线程独占
            setTimeout(function () {
                darkMode();
            }, 0);
        }
        $("body").on('click', '.cutover', function () {
            darkMode();
        })
    })

    function darkMode() {
        cutoverColor = !cutoverColor;
        window.localStorage.setItem('cutoverColor', !cutoverColor);
        if (cutoverColor) {
            document.documentElement.style.setProperty('--main', '#515a6e');
            document.documentElement.style.setProperty('--mainfont', '#fff');
            document.documentElement.style.setProperty('--headfont', '#fff');
            document.documentElement.style.setProperty('--color-list', '#fff');
            document.documentElement.style.setProperty('--color-tag', '#fff');
            $(".fullScreen").css("background","#000");
        } else {
            document.documentElement.style.setProperty('--main', '#ffff');
            document.documentElement.style.setProperty('--mainfont', '#515a6e');
            document.documentElement.style.setProperty('--headfont', '#2c3e50');
            document.documentElement.style.setProperty('--color-list', '#666');
            document.documentElement.style.setProperty('--color-tag', '#999');
            $(".fullScreen").css("background","");
        }
    }
</script>

<style>
    a {
        text-decoration: none
    }

    body {
        margin: 0 auto;
    }backstretch-item

    .fullScreen {
        left: 0px;
        top: 0px;
        overflow: hidden;
        margin: 0px;
        padding: 0px;
        height: 100%;
        width: 100vw;
        z-index: -999998;
        position: fixed;
    }
</style>
</body>
</html>
